Skip to content

feat: filter sub-language read half β€” runtime + studio (RFD 0021)#4

Merged
softmarshmallow merged 2 commits into
mainfrom
filter-read-half
Jul 13, 2026
Merged

feat: filter sub-language read half β€” runtime + studio (RFD 0021)#4
softmarshmallow merged 2 commits into
mainfrom
filter-read-half

Conversation

@softmarshmallow

Copy link
Copy Markdown
Member

Summary

Implements the read half of the filter sub-language (RFD 0021), end to end β€” one owned predicate IR, two borrowed frontends (Hasura bool_exp for GraphQL, PostgREST operators for REST), one parameterized SQLite WHERE β€” and wires it into the studio console with a Supabase-style Filter/Sort UX. This was the roadmap's five-times-deferred query layer; it also discharges the pagination/ordering debt for derived read surfaces.

Runtime (crates/spock-runtime)

  • src/filter.rs (new) β€” the predicate IR (And/Or/Not/Cmp/In/IsNull/Const) and injection-proof lowering: operators come from a closed enum through a fixed match arm, columns are validated + double-quoted, and every value is a bound ? (the only length-variable emission is the ? count in an IN list). Owns the forced stable total order (user terms + primary-key tiebreak in the last direction, with explicit NULLS placement since SQLite's implicit default is inverted) and paging (page cap + offset-depth ceiling). Also houses the PostgREST frontend parse_rest (recursive and/or/not groups, not. prefix, is.{null,true,false}).
  • graphql.rs β€” the Hasura bool_exp frontend: per-table <t>_bool_exp / <t>_order_by inputs, <scalar>_comparison_exp, the order_by enum; where / order_by / offset args on list roots and reverse collections; query_rows rewritten onto the single composer.
  • http.rs β€” list_rows takes ordered query params and lowers via parse_rest; a startup guard reserves the control keys (order/limit/offset/select/and/or/not) against column names.

v0 operators: eq/neq/gt/gte/lt/lte/in/nin/is_null/ilike + and/or/not.

  • _like (case-sensitive) is refused β€” the SQLite floor's LIKE is ASCII-case-insensitive and the case-sensitive form needs the banned PRAGMA case_sensitive_like; _ilike ships. The Postgres-only tail (regex/fts/jsonb/…) is a loud bad_request.
  • Ref fields are typed as <target>_bool_exp; the key sub-field folds to a direct FK comparison (non-key sub-fields reserved β†’ bad_request).
  • Keyset cursors are not productized in v0 (flat IR has no tuple node) β€” offset paging with a 10k depth ceiling.

Studio (crates/spock-runtime/studio)

  • views/table-view.tsx β€” replaces the "waits on the filter RFD" placeholder with a Filter popover ([column][operator][value] rows + active-count badge) and a Sort popover (column picker + asc/desc toggle), plus an offset pager, all wired to the REST predicate. The operator menu is symbol+label (like deliberately absent, mirroring the floor's refusal); closed-set and bool columns get value dropdowns; unary is null/is not null hide the value; refusals (unknown_field/type_mismatch) surface in the grid. Re-fetches only when the effective query actually changes.
  • components/ui/popover.tsx (new) β€” shadcn wrapper over @base-ui/react/popover, matching the existing select.tsx wrapper.
  • lib/query.ts (new) β€” the operator model + the filter/sort/page β†’ PostgREST query-string builder (the client mirror of filter.rs).

Fixture + docs

  • examples/filter-lab/ (new) β€” a deliberately domain-less technical fixture (widget + edge) that stresses every scalar / closed-set / nullable / ref / composite-key path plus the sharp edges: ordering ties, NULL placement, %/_ wildcard escaping, ASCII case folding. FEEDBACK.md records the F1–F7 dogfood findings.
  • docs β€” RFD 0021 β†’ accepted / read-half shipped; RFD 0009 roadmap marks the filter dialect ratified/shipped; graphql.md Β§7 strikes _like.

Verification

  • cargo test 201 passed / 0 failed, cargo clippy clean, cargo fmt --check clean.
  • Studio tsc + oxlint + pnpm build (production) clean.
  • Read path exercised end to end against filter-lab over REST, GraphQL, and the studio UI (filter, sort, enum-value dropdown, is not null, tie-stable ordering, the offset pager).

Reviewer notes

  • The predicate IR lives in spock-runtime (protocol), not the spock-lang contract IR β€” filters are per-request, never .spock source. Both floors converge on one composer, which is the intended chokepoint for the v1 policy/RLS dry-run (leaf-parametric Operand, reserved un-emitted Exists).
  • The studio changes went through a /simplify cleanup pass (helper extraction, dead-code removal, a plain-button sort picker replacing a misused controlled Select).
  • Filtered/bulk writes and the v1 policy engine build on this same IR and are intentionally out of scope here.
  • Local .claude/ preview config and the gitignored studio/dist/ bundle are intentionally not committed (CI rebuilds dist).

Implements the read half of the filter sub-language: one owned predicate IR,
two borrowed frontends, one parameterized SQLite WHERE. Discharges the
pagination/ordering debt for derived read surfaces.

Runtime (crates/spock-runtime):
- src/filter.rs (new): the predicate IR (And/Or/Not/Cmp/In/IsNull/Const) and
  injection-proof lowering β€” operators from a closed enum, columns validated +
  double-quoted, every value a bound `?`. Owns the forced stable total order
  (user terms + pk tiebreak in the last direction, explicit NULLS placement)
  and paging (page cap + offset-depth ceiling). Includes the PostgREST
  frontend (parse_rest) with recursive and/or/not groups and not.-prefix.
- graphql.rs: the Hasura bool_exp frontend β€” per-table <t>_bool_exp /
  <t>_order_by inputs + <scalar>_comparison_exp + the order_by enum; where /
  order_by / offset args on list roots and reverse collections; query_rows
  rewritten onto the one composer.
- http.rs: list_rows takes ordered query params and lowers via parse_rest;
  reserved-column startup guard (order/limit/offset/select/and/or/not).
- v0 ops: eq/neq/gt/gte/lt/lte/in/nin/is_null/ilike + and/or/not. `_like`
  (case-sensitive) is refused β€” the SQLite floor is ASCII-case-insensitive and
  the case-sensitive form needs the banned PRAGMA. Postgres-only tail is a
  loud bad_request. Ref fields typed as <target>_bool_exp; the key sub-field
  folds to a direct FK compare. Keyset cursors deferred (offset + 10k ceiling).

Studio (crates/spock-runtime/studio) β€” the Supabase-style console surface:
- views/table-view.tsx: Filter + Sort popovers (Supabase table-editor UX) and
  an offset pager, wired to the REST predicate. Operator menu is symbol+label;
  closed-set/bool columns get value dropdowns; unary is-null/not-null hide the
  value. Refusals surface in the grid. Re-fetches only when the effective
  query changes.
- components/ui/popover.tsx (new): shadcn wrapper over @base-ui/react/popover.
- lib/query.ts (new): the operator model + the filter/sort/page -> PostgREST
  query-string builder (the client mirror of filter.rs).

Fixture + docs:
- examples/filter-lab (new): a domain-less technical fixture (widget + edge)
  stressing every scalar/closed-set/nullable/ref/composite-key path plus
  ordering ties, NULL placement, %/_ wildcard escaping and ASCII case folding;
  FEEDBACK.md records the F1-F7 dogfood findings.
- docs: RFD 0021 -> accepted/read-half shipped; RFD 0009 roadmap marks the
  filter dialect ratified/shipped; graphql.md Β§7 strikes `_like`.

Verified: 201 tests + clippy + fmt green; studio tsc/oxlint/pnpm build clean;
read path exercised end-to-end (REST + GraphQL + studio) against filter-lab.

Filtered/bulk writes and the v1 policy engine build on this same IR (deferred).
@coderabbitai

coderabbitai Bot commented Jul 13, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@softmarshmallow, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 29 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
βš™οΈ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 387aae00-bb94-4c54-8e90-8eb6edb9b0b6

πŸ“₯ Commits

Reviewing files that changed from the base of the PR and between f93f87c and 7297a96.

πŸ“’ Files selected for processing (5)
  • crates/spock-runtime/src/filter.rs
  • crates/spock-runtime/src/graphql.rs
  • crates/spock-runtime/studio/src/views/table-view.tsx
  • crates/spock-runtime/tests/filter.rs
  • crates/spock-runtime/tests/graphql.rs

Walkthrough

Changes

The runtime now provides a shared predicate IR for REST and GraphQL filtering, ordering, pagination, validation, and bound SQLite queries. The studio adds filter and sort controls, while integration tests, a filter fixture, and RFD/spec documents describe and validate the read surface.

Filter read surface

Layer / File(s) Summary
Predicate IR and REST parser
crates/spock-runtime/src/filter.rs, crates/spock-runtime/src/lib.rs
Defines predicate types, SQL lowering, typed coercion, limits, ordering, and PostgREST query parsing.
GraphQL filter execution
crates/spock-runtime/src/graphql.rs
Registers filter input types and applies where, order_by, pagination, and relationship predicates to list queries.
REST endpoint integration
crates/spock-runtime/src/http.rs
Validates reserved filter columns and executes parsed REST filters with bound parameters.
Studio query controls
crates/spock-runtime/studio/src/lib/query.ts, crates/spock-runtime/studio/src/components/ui/popover.tsx, crates/spock-runtime/studio/src/views/table-view.tsx
Adds REST query serialization and table UI for filters, sorting, paging, refresh, and structured errors.
End-to-end filter validation
crates/spock-runtime/tests/filter.rs, crates/spock-runtime/tests/graphql.rs, crates/spock-runtime/tests/http.rs, examples/filter-lab/schema.spock
Tests operators, ordering, null semantics, relationships, composite keys, refusals, pagination, and startup validation against seeded fixtures.
Filter specification updates
docs/rfd/0009-roadmap.md, docs/rfd/0021-filter.md, docs/spec/graphql.md, examples/filter-lab/FEEDBACK.md
Documents the shipped read half, pending writes, supported semantics, and recorded edge cases.

Estimated code review effort: 5 (Critical) | ~120 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant RESTOrGraphQL
  participant PredicateParser
  participant query_rows
  participant SQLite
  Client->>RESTOrGraphQL: filter, order, limit, offset
  RESTOrGraphQL->>PredicateParser: parse request
  PredicateParser->>query_rows: Predicate and ordering
  query_rows->>SQLite: bounded SQL with parameters
  SQLite-->>Client: ordered result page
Loading
πŸš₯ Pre-merge checks | βœ… 5
βœ… Passed checks (5 passed)
Check name Status Explanation
Title check βœ… Passed The title is concise and accurately summarizes the main change: implementing the filter sub-language read half in runtime and studio.
Description check βœ… Passed The description clearly matches the changeset and explains the runtime, studio, fixtures, and docs updates in scope.
Docstring Coverage βœ… Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check βœ… Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check βœ… Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
πŸ§ͺ Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch filter-read-half

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❀️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
crates/spock-runtime/studio/src/views/table-view.tsx (1)

138-156: 🩺 Stability & Availability | 🟠 Major | ⚑ Quick win

Stale response can overwrite fresher rows β€” no request sequencing.

load() has no guard against out-of-order responses. Filter/sort/paging edits each independently call reloadWith/maybeLoad β†’ load(), and with several rapid edits in flight, an older request's response can resolve after a newer one and silently replace the up-to-date rows/error state with stale data.

this.lastQuery is already updated synchronously right before the await, so it can double as a staleness check on the way back:

🩹 Proposed fix
     const qs = buildQuery(filters, sorts, limit, offset)
     this.lastQuery = qs
     const res = await api(`/rest/v1/${encodeURIComponent(table.name)}?${qs}`, this.context.actor)
+    // a newer load() may have started (and rewritten lastQuery) while this
+    // request was in flight β€” drop the now-stale response
+    if (qs !== this.lastQuery) return
     if (res.status !== 200) {
πŸ€– Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/spock-runtime/studio/src/views/table-view.tsx` around lines 138 - 156,
Update the async load method to ignore stale responses: after the await api call
and before applying either the error state or rows, compare the request’s
captured query against this.lastQuery and return when they differ. Preserve the
existing state updates for the latest request only, using the synchronous
lastQuery assignment in load as the request-sequencing marker.
🧹 Nitpick comments (2)
crates/spock-runtime/src/filter.rs (1)

502-514: 🎯 Functional Correctness | πŸ”΅ Trivial | ⚑ Quick win

ilike isn't restricted to text columns on the REST frontend.

Unlike the GraphQL side (where _ilike only exists on String_comparison_exp), parse_column_op builds CmpOp::Ilike for any resolved column without checking its value type, so ?age=ilike.5* on an integer column silently produces a LIKE on non-text data (SQLite coerces rather than erroring). This is a safe-degradation gap, but it diverges from the module's fail-loud stance elsewhere (set membership, is.true/false boolean check). Consider rejecting ilike on non-text/set columns with a type_mismatch.

πŸ€– Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/spock-runtime/src/filter.rs` around lines 502 - 514, Update
parse_column_op’s "ilike" branch to validate that the resolved column is
text-compatible before constructing Predicate::Cmp. Reject non-text and
set-valued columns with the existing type_mismatch error, while preserving
wildcard replacement and CmpOp::Ilike behavior for text columns.
crates/spock-runtime/tests/graphql.rs (1)

941-959: πŸ“ Maintainability & Code Quality | πŸ”΅ Trivial | ⚑ Quick win

Comment promises _and, test doesn't exercise it.

The header comment lists _and/_or/_not, but only _or (line 944) and _not (line 952) are tested. No case builds where: {_and: [...]}.

Suggested addition
     let resp = gql(
         &base,
         r#"{ user(where: {_not: {username: {_eq: "maya"}}}) { username } }"#,
         Value::Null,
     )
     .await;
     assert_no_errors(&resp);
     let users = resp["data"]["user"].as_array().unwrap();
     assert_eq!(users.len(), 1);
     assert_eq!(users[0]["username"], "luis");
+
+    let resp = gql(
+        &base,
+        r#"{ post(where: {_and: [{caption: {_ilike: "%first%"}}, {caption: {_ilike: "%light%"}}]}) { caption } }"#,
+        Value::Null,
+    )
+    .await;
+    assert_no_errors(&resp);
+    assert_eq!(resp["data"]["post"].as_array().unwrap().len(), 1);
πŸ€– Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/spock-runtime/tests/graphql.rs` around lines 941 - 959, Add an
explicit GraphQL integration case in the `_and / _or / _not` test block that
queries `where: {_and: [...]}` with multiple caption predicates, then assert no
errors and verify the returned rows match the expected intersection. Keep the
existing `_or` and `_not` assertions unchanged.
πŸ€– Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/spock-runtime/src/filter.rs`:
- Around line 417-465: Add a maximum nesting depth of 32 to the recursive
parse_group/parse_group_member flow, passing and incrementing the depth as
nested groups are parsed and returning ApiError::bad_request when the limit is
exceeded. Ensure both normal and negated and/or groups enforce the cap before
recursing, preventing unbounded stack growth and substring rebuilding.

In `@crates/spock-runtime/src/graphql.rs`:
- Around line 873-905: The parse_order_json function must not silently reorder
keys in the lone-object order_by form. Reject object inputs containing multiple
fields, or otherwise restrict multi-key ordering to the array/list form, while
preserving existing validation and support for single-key objects.

In `@crates/spock-runtime/studio/src/views/table-view.tsx`:
- Around line 186-193: Update fSetColumn so changing a filter column also resets
its existing value and operator to the appropriate empty/default state before
the reload. Preserve mapRule’s behavior and ensure the updated filter cannot
retain a value or op incompatible with the newly selected column.

---

Outside diff comments:
In `@crates/spock-runtime/studio/src/views/table-view.tsx`:
- Around line 138-156: Update the async load method to ignore stale responses:
after the await api call and before applying either the error state or rows,
compare the request’s captured query against this.lastQuery and return when they
differ. Preserve the existing state updates for the latest request only, using
the synchronous lastQuery assignment in load as the request-sequencing marker.

---

Nitpick comments:
In `@crates/spock-runtime/src/filter.rs`:
- Around line 502-514: Update parse_column_op’s "ilike" branch to validate that
the resolved column is text-compatible before constructing Predicate::Cmp.
Reject non-text and set-valued columns with the existing type_mismatch error,
while preserving wildcard replacement and CmpOp::Ilike behavior for text
columns.

In `@crates/spock-runtime/tests/graphql.rs`:
- Around line 941-959: Add an explicit GraphQL integration case in the `_and /
_or / _not` test block that queries `where: {_and: [...]}` with multiple caption
predicates, then assert no errors and verify the returned rows match the
expected intersection. Keep the existing `_or` and `_not` assertions unchanged.
πŸͺ„ Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
βš™οΈ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 11b49102-4220-4284-9045-796e46b03d78

πŸ“₯ Commits

Reviewing files that changed from the base of the PR and between 2f78e32 and f93f87c.

πŸ“’ Files selected for processing (15)
  • crates/spock-runtime/src/filter.rs
  • crates/spock-runtime/src/graphql.rs
  • crates/spock-runtime/src/http.rs
  • crates/spock-runtime/src/lib.rs
  • crates/spock-runtime/studio/src/components/ui/popover.tsx
  • crates/spock-runtime/studio/src/lib/query.ts
  • crates/spock-runtime/studio/src/views/table-view.tsx
  • crates/spock-runtime/tests/filter.rs
  • crates/spock-runtime/tests/graphql.rs
  • crates/spock-runtime/tests/http.rs
  • docs/rfd/0009-roadmap.md
  • docs/rfd/0021-filter.md
  • docs/spec/graphql.md
  • examples/filter-lab/FEEDBACK.md
  • examples/filter-lab/schema.spock

Comment thread crates/spock-runtime/src/filter.rs Outdated
Comment thread crates/spock-runtime/src/graphql.rs
Comment thread crates/spock-runtime/studio/src/views/table-view.tsx
- filter.rs: cap REST logical-group nesting at 32 (MAX_FILTER_DEPTH) β€” an
  unbounded ?and=(and(and(...))) could overflow the stack / burn superlinear
  work before returning bad_request. Mirrors GraphQL's .limit_depth(32).
- filter.rs: reject `ilike` on non-text columns (type_mismatch) β€” mirrors the
  GraphQL side where `_ilike` lives only on String; SQLite would coerce silently.
- graphql.rs: reject multi-key `order_by` objects β€” serde_json::Map iterates in
  sorted key order, so {b: desc, a: asc} silently reordered to a,b; the list
  form carries multiple terms.
- studio/table-view.tsx: clear a filter's value when its column changes (a
  wrong-typed value would type_mismatch and show a non-option in set/bool
  dropdowns); drop stale in-flight load() responses via the lastQuery marker so
  a slow older request can't clobber fresher rows.
- tests: add _and GraphQL case; add REST refusals for ilike-on-int, deep
  nesting, and multi-key order_by.

All green: 201 tests + clippy + fmt; studio tsc/oxlint/pnpm build; column-clear
verified live.
@softmarshmallow softmarshmallow merged commit 8a89813 into main Jul 13, 2026
2 checks passed
@softmarshmallow softmarshmallow deleted the filter-read-half branch July 13, 2026 07:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant